fix(task-graph): cap bridgeSubGraphTaskEvents depth to prevent event amplification#601
fix(task-graph): cap bridgeSubGraphTaskEvents depth to prevent event amplification#601sroussey wants to merge 3 commits into
Conversation
…amplification bridgeSubGraphTaskEvents installs one re-emit listener per event type per nesting level. A deeply nested compound task (e.g., a MapTask containing GraphAsTask containing MapTask) turns one inner task_progress into N parent emits before reaching any wire subscriber. Downstream consumers with a bounded event log (the sec/builder ExecutionEventLog is capped at 10k events) can evict legitimate events under sustained nested fan-out. Add a default depth cap of 16 with a logged drop once exceeded, and let callers override via the new maxDepth parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3e3m8cMRy5stFhDzGmZrF
Coverage Report
File CoverageNo changed files found. |
…arn per parent Without the saturating stamp, when bridgeSubGraphTaskEvents short-circuits at the depth cap, the subgraph is left without a BRIDGE_DEPTH marker — so the next bridge level reads depth as undefined (0) and the cap leaks downstream. Stamp the subgraph at maxDepth before the early return so every nested bridge also short-circuits. Also gate the warn through a module-level WeakSet keyed by parent graph: an iterator with 1k iterations at over-cap was previously emitting 1k warns per run.
There was a problem hiding this comment.
Pull request overview
This PR adds a defense-in-depth cap to bridgeSubGraphTaskEvents to prevent pathological event amplification in deeply nested compound-task graphs, and introduces tests to validate the new behavior.
Changes:
- Add depth tracking + a default max depth (16) to drop bridging beyond the cap and emit a warning.
- Re-export
bridgeSubGraphTaskEventsfrom@workglow/task-graph’s common entry so tests can call it directly. - Add a new test suite covering default cap behavior, warning emission, maxDepth overrides, and warning de-duplication.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/task-graph/src/task-graph/SubGraphEventBridge.ts | Implements depth cap logic, stamping, and warn-once behavior to prevent event amplification. |
| packages/task-graph/src/common.ts | Re-exports SubGraphEventBridge to make bridgeSubGraphTaskEvents available from the package entrypoint. |
| packages/test/src/test/task-graph/TaskCompleteEvent.test.ts | Adds tests for depth-capping behavior, logging fields, override behavior, and warning de-duplication. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (depth >= maxDepth) { | ||
| // Stamp the subgraph at the cap so any nested bridge call (whose | ||
| // parentGraph is this subgraph) derives `depth >= maxDepth` and also | ||
| // short-circuits — otherwise the depth counter resets at the next level | ||
| // and the cap leaks downstream. | ||
| (subGraph as unknown as Record<symbol, number>)[BRIDGE_DEPTH] = maxDepth; | ||
| if (!warnedParents.has(parentGraph)) { | ||
| warnedParents.add(parentGraph); | ||
| getLogger().warn("bridgeSubGraphTaskEvents depth cap hit; dropping bridge", { | ||
| depth, | ||
| maxDepth, | ||
| }); | ||
| } | ||
| return () => {}; | ||
| } |
There was a problem hiding this comment.
Fixed in the latest commit. The depth >= maxDepth branch now saves previousDepth from subGraph before stamping and restores (or deletes) it in the returned teardown, identical to the pattern used in the normal path.
| // Emit a task_progress from the innermost graph and count how many bridges | ||
| // it traversed up to the outermost. Without the cap this would re-emit N | ||
| // times (once per parent); with the cap of 16 it stops after 16 hops. | ||
| const reachedDepths: number[] = []; | ||
| for (let i = 0; i < graphs.length; i++) { | ||
| const depth = i; | ||
| graphs[i].subscribe("task_progress", () => { | ||
| reachedDepths.push(depth); | ||
| }); | ||
| } | ||
|
|
||
| graphs[N].emit("task_progress", "inner-id", 42, "msg"); | ||
|
|
||
| // Innermost (depth N) saw the original emit; bridges propagate up at most | ||
| // 16 levels (the default cap), so the highest depth reached is N - 16. | ||
| const minReached = Math.min(...reachedDepths); | ||
| expect(minReached).toBeGreaterThanOrEqual(N - 16); | ||
| // The cap fired (depth reached 16), producing at least one warn. | ||
| expect(warnSpy).toHaveBeenCalled(); |
There was a problem hiding this comment.
Fixed in the latest commit. The test now uses N = 17 and asserts the exact boundary: emitting from graphs[16] (depth 15, within cap) must reach graphs[0], while emitting from graphs[17] (depth 16, at cap) must not.
|
Superseded by #606, which cherry-picks this PR's depth-cap change onto a fresh Generated by Claude Code |
Summary
bridgeSubGraphTaskEventsinstalls one re-emit listener per event type per nesting level. A deeply nested compound task (e.g. aMapTaskcontaining aGraphAsTaskcontaining aMapTask…) bridges every level independently, so a single innertask_progressbecomes N parent emits before reaching any wire subscriber. Combined with downstream consumers that keep a bounded event log (the sec/builderExecutionEventLogis capped at 10k events), sustained nested fan-out can silently evict legitimate events.This PR adds a defense-in-depth depth cap with a default of 16 levels. Once exceeded, the bridge installs no listeners and emits a single
warnlog noting the cap hit. The cap is overridable via a newmaxDepthparameter; depth is auto-derived from a symbol-keyed marker on the parent graph so existing call sites (GraphAsTaskRunner,WhileTask,IteratorTaskRunner,FallbackTaskRunner,GraphAsTaskstreaming path) compile unchanged.Approach
Depth tracking lives on the graph instance via a
Symbol.for("@workglow/task-graph/SubGraphEventBridge.depth")keyed property — chosen over threading a counter through every caller because it keeps the diff to one file of fix code plus one re-export. The teardown restores the previous marker so an independently-rooted later bridge of the same subgraph instance does not inherit a stale counter.Files
packages/task-graph/src/task-graph/SubGraphEventBridge.ts— depth cap + warn logpackages/task-graph/src/common.ts— re-exportbridgeSubGraphTaskEventsso the test can drive it directlypackages/test/src/test/task-graph/TaskCompleteEvent.test.ts— three new testsBackwards compatibility
Patch-bump compatible: both new parameters have defaults; every existing caller still compiles. No behavior change for graphs nesting fewer than 16 bridges deep.
Test plan
bun scripts/test.ts graph vitest— 74 files, 728 tests passing (including 3 new)bun run build:types— 36 successful, 36 total{ depth, maxDepth }fields;maxDepthoverride is honoredGenerated with Claude Code
https://claude.ai/code/session_01V3e3m8cMRy5stFhDzGmZrF
Generated by Claude Code